
前兩天談的都是命題,今天讓案例登場。
案例是 Northwind,微軟那套從 Access 年代流傳到現在的範例資料庫。選它的理由很現實:客戶、供應商、產品、訂單這幾張表,多數人不需要解釋就知道是什麼,於是可以直接談這個結構考驗了框架什麼,不必先講這個業務在做什麼。
本篇說明:
先看做出來的樣子。這是八張表單裡最複雜的訂單,同一份定義在四個前端上跑出來的畫面。
| 桌面 | 瀏覽器 |
|---|---|
![]() |
![]() |
| iOS | Android |
|---|---|
![]() |
![]() |
主檔配一張明細,客戶、員工、貨運商三個欄位都是開窗挑一筆回來,明細每列再挑一項產品,金額不給人填。四個前端共用同一個 UI 專案、讀同一份定義,沒有一張畫面有對應的畫面程式碼,差別只在最外面的那一層平台外殼。
框架的示範幾乎都長一樣:一張單表,欄位排一排,增修刪查會動。這種示範什麼都證明不了,因為所有框架都做得到。真正會壞的地方,一個都不在那張示範上。
一個能當驗證用的案例,至少得同時具備四個條件:
Northwind 四項都撐得住。它的複雜度落在一個好位置:夠複雜到能考驗機制,又夠小到能整套攤開來逐項對帳。
| 形狀 | 張數 | 表單 |
|---|---|---|
| 純主檔(欄位、清單、CRUD,無關連無規則) | 4 | 分類、供應商、客戶、貨運商 |
| 帶跨表關連的主檔 | 3 | 產品(→ 供應商、分類)、部門(→ 員工,作為經理)、員工(→ 部門) |
| 主檔配明細的單據 | 1 | 訂單 |
部門與員工互相指向,是刻意留的環狀關連。
八張表單對應九張資料表(訂單佔兩張)。九張裡有兩張是框架提供的表:部門與員工。權限與組織機制會用到它們,應用把定義複製過來直接用(框架的表與應用的表怎麼區分、各自落在哪個資料庫,Day 11 會專門談)。
借的是業務案例與示範資料,不是資料表結構。原本的 Northwind 拿業務欄位當主鍵(CustomerID='ALFKI'、整數產品編號、訂單明細的複合主鍵),這套應用一個都沒沿用。
換上的是框架的系統欄位,而且框架的機制就掛在這幾個名字上:
| 欄位 | 框架拿來做什麼 |
|---|---|
sys_rowid |
跨表 JOIN、UPDATE / DELETE 的 WHERE、稽核、資料範圍權限、前端點選一列,比對的都是這一欄 |
sys_master_rowid |
明細怎麼撈、整筆怎麼一起存 |
sys_no |
主鍵與預設排序 |
sys_id |
建表時自動配唯一索引,業務代碼的唯一性由此擔保 |
sys_name |
開窗選取時顯示的那一欄 |
所以這幾個名字不能改,這不是命名慣例的問題。上面那幾件事,框架全靠它們去認。
兩個設計決定與它們的代價:
sys_no,關連鍵是 Guid 型的 sys_rowid。關連鍵要不會變(代碼改了,舊單據不能斷),也要在寫進資料庫之前就存在(整筆訂單一次存,明細得先指到主檔)。代價是多一個唯一索引,鍵也比較胖。還有一項代價比較重:既有系統要接進來得先改資料表結構,不是把定義寫一寫就能跑在原本的資料庫上。
傳統做法裡關連是資料表的事:訂單表放一個客戶欄位,加一條外鍵指到客戶表主鍵。這條宣告能表達的剛好就是字面那麼多,這一欄的值必須在那一欄裡找得到。
框架把關連挪到另一個層次:
<FormField FieldName="customer_rowid" Caption="Customer" DbType="Guid" RelationProgId="Customer">
<RelationFieldMappings>
<FieldMapping SourceField="sys_id" DestinationField="ref_customer_id" />
<FieldMapping SourceField="sys_name" DestinationField="ref_customer_name" />
</RelationFieldMappings>
</FormField>
RelationProgId="Customer" 指的是「客戶」這支程式,不是 ft_customer 這張表。整段宣告從頭到尾沒有出現表名,要 JOIN 到哪一張表,是框架拿這個代號去查客戶那份 FormSchema 才知道的。
指向表單和指向資料表,能接上的東西差很多:
FormSchema 還知道:清單顯示哪幾欄、開窗挑選時給人看什麼、哪些欄位能查詢、它自己要不要再加條件(例如只列啟用中的供應商)所以寫完上面那一段,四件事同時到位:
傳統做法這四件分記在三個地方(外鍵在資料庫、選取畫面設定在前端、JOIN 在查詢層),三處都可能對不起來。
框架不建外鍵約束,訂單那三個關連欄位在資料庫裡只有索引。
這是把關連挪到定義層的必然結果:關連的權威在定義層,資料庫只是它投影出來的產物。真要在資料庫也建一份外鍵,那份宣告就有兩個來源,而兩個來源遲早分歧。
代價:資料庫不會替你擋孤兒資料。有人直接下 SQL 刪掉一個客戶,指向它的訂單不會被攔下來。願不願意接受,取決於你的資料庫是不是只有這個應用在寫。「定義是唯一真相」這個假設要一路貫徹到資料庫層,不能一半交給定義、一半交給約束。
ref_customer_id 與 ref_customer_name 這兩個欄位在資料表裡並不存在。
典型取捨:每次查詢多一次 JOIN,換到的是畫面上永遠是現在的值。代價是要有一個明確的規定說「哪一邊是權威」,而這個規定必須由框架來定,不能讓每張表單各自決定。
實測八處:訂單主檔三處(客戶、員工、貨運商)、訂單明細每列一處(產品)、產品兩處(供應商、分類)、員工一處(部門)、部門一處(經理)。
Day 1 說的「三處跨表關連」是訂單主檔上的那三處,後面對帳用的也是這個範圍。
這八處裡有一處跨過一條線:訂單指向的員工是前面那兩張框架表之一。訂單上的業務員就是框架的員工,應用不必再建一張自己的員工表。
部門指向員工(誰是經理)、員工指向部門,在資料模型上是一個環。刻意留著,因為凡是「從定義產生東西」的機制遇到環都要有明確處理方式:
這正是 Day 1「產生的是程式碼還是物件」在資料模型上的投影。
八張表單裡七張的應用程式碼是零行。註冊表上這七張只寫了 ProgId 與顯示名稱,代表走框架預設流程。只有訂單那一列指定了兩個型別:
<ProgramItem ProgId="Order" DisplayName="Orders"
BusinessObject="...OrderBO, Bee.Northwind.Server"
Repository="...OrderRepository, Bee.Northwind.Server" />
兩個型別各管一段:Business Object(以下簡稱 BO)放這張表單的業務邏輯,Repository 放它的資料存取。
那個 Repository 存在的理由只有兩句 SQL:讀出訂單目前存在資料庫裡的狀態,以及查出本月最大的訂單編號。其餘 CRUD 仍由框架依定義組出來。
把這兩句放進 Repository 而不是 BO,不只是分層潔癖:BO 得自己指名連哪一個資料庫,Repository 則由 FormSchema 的分類加上目前的 session 解析出來,自動走到正確那一家公司的資料庫,寫的人不必知道系統裡有幾家公司。
訂單的 BO 覆寫兩個方法,存檔之前那一步做了四件事:
四件看起來都像「業務邏輯」,但拿 Day 2 那條判別法(全世界的 ERP 做起來是不是都一樣)去切,只有最後一件是。
前三件都是通則。單據至少要有一列明細、總計等於明細加總,這是約定俗成的做法;訂單編號是前綴加年月加流水號,這種取號原則多數 ERP 都做成可設定的機制。三件應該都能收斂為機制,它們現在寫在應用裡是因為框架還沒收。
最後一件才是業務判斷。狀態從草稿到確認到出貨,不能跳階、不能回頭,確認後明細鎖住。這條規則每一家都不一樣(有些公司確認後還能改數量,有些多兩個中間狀態),該由應用寫。
BO 一開始做的不只這四件,有兩批東西已經搬出去了:
FormSchema 裡的三條規則<FormField FieldName="amount" DbType="Currency" NumberKind="Amount" ReadOnly="true"
ValueExpression="quantity * unit_price * (1 - discount)" />
搬過去之後省的不只是那幾行:運算式在使用者改動數量或折扣的當下就重算,於是前端不必為了「金額要即時跳動」再寫一次同樣的計算。同一個算式,一處宣告,兩端都算得出來。
還有一件走得更遠:能改它的人也跟著換了。金額怎麼算現在寫在定義裡,要調整就是改一份定義檔,不必回到程式碼那一側。每有一件事從程式搬進定義,動得了它的人就往需求端靠一步:原本得排進開發排程的東西,變成懂業務的人看得懂、也改得動的一行算式。
所以「哪些該寫程式」這條線不是一開始畫定的,它會隨框架的宣告能力往前推。剛才那四件裡,前三件遲早會被收走,剩下的只有最後那一件。
留在程式碼裡的東西,一部分本來就該在那裡,一部分只是還沒被接管。混在一起看不出差別,分開看才知道下一步該收哪一個。
框架管的是每張表單都一樣的事:查清單、開一筆、新增、修改、刪除、開窗挑選、存檔前驗證、把資料送到前端。這些事的形狀不隨表單改變,會變的只是欄位有哪些,而欄位就寫在定義裡。控管也一樣:誰能存取、哪些資料看得到、每次異動是誰在什麼時候改了什麼。
所以那七張表單,應用一行程式都沒寫。
講到這裡通常會冒出一個疑慮,而且是該冒出來的:流程都標準化了,那彈性呢?
這一題比「框架能做多少」重要得多。訂標準不難,難的是訂完之後還留不留得住例外。留不住,第一次碰到特殊需求就得整條流程自己刻,前面省的全部還回去。案例裡碰到這件事的就是訂單那一張。
接手有兩種方式:
| 方式 | 機制 | 性質 |
|---|---|---|
| 繼承後改寫 | 框架的 BO 把整條流程切成一連串可覆寫的步驟(取一筆新資料、存檔之前、存檔本身、存檔之後,刪除同樣切法),應用只改需要的那幾步。訂單只動了兩步 | 取代,一支程式一個 |
| 掛外掛 | 框架在存檔與刪除的前後開了四個時點讓外部邏輯掛進去 | 疊加,可疊多個,客製那條鏈接在套裝那條後面,不動原程式 |
兩種方式的共同點才是對那個疑慮的回答:接手的是流程裡的一小段,不是整條流程。覆寫「存檔之前」不會讓你連 SQL 怎麼組、資料怎麼送到前端都得自己來。
彈性不在於框架什麼都做得到,而在於你只需要處理跟機制做法不同、或機制還沒支援的那一部分。標準流程與例外能不能共存,看的就是這個顆粒度:切得太粗,例外一出現就得整條重寫;切得夠細,標準的部分就繼續替你做。
Day 1 說:整套 Northwind 做完,八張表單裡只有一張需要自己寫程式。那一張就是訂單,而它需要接手不是因為比較複雜,是因為它碰到了框架現行機制管不到的那幾件。案例需要有這樣一張表單,否則「大部分不用寫程式」這句話等於沒被驗證過。
而處理它們,覆寫框架切出來的那兩步就夠了,不必離開那條標準流程。這個空間是必要的,機制不能為了把流程標準化就把彈性收掉。ERP 裡的特例有很多不是需求寫壞了,是那家公司的組織與作業慣例本來就長那樣,不會因為框架訂了標準就消失(框架把接手的深度切成幾段,Day 30 會整個攤開)。
這就是 Definition-Driven 想達到的狀態:不是不用寫程式,是只在真正需要判斷的地方寫,而且寫的時候只接手那一小段。
明天開始進定義層,先把十三種定義檔逐一交代清楚。
本系列同步發表於 HackMD,完整目錄